🎖️GitЯра🎖️
Commit 17501048b4f8986b1ceca3cf90b9bf9da5ec9c30
Parents : e12c4a5
Author : James Rich <2199651+jamesarich@users.noreply.github.com>
Signature : Signature validation error
Date : 2026-07-07T18:24:14-05:00
Committer : GitHub <noreply@github.com>
Date : 2026-07-07T23:24:14Z
feat(map): Site Planner coverage integration — import + estimate (#6136)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Changes
18 files changed, 1267 insertions(+), 10 deletions(-)
Diff
diff --git a/.skills/compose-ui/strings-index.txt b/.skills/compose-ui/strings-index.txt
index b494598cab..9c0f3577c6 100644
--- a/.skills/compose-ui/strings-index.txt
+++ b/.skills/compose-ui/strings-index.txt
@@ -1450,6 +1450,32 @@ shutdown_on_power_loss
shutdown_warning
signal
signal_quality
+### SITE ###
+site_planner
+site_planner_antenna_gain_dbi
+site_planner_antenna_height_meters
+site_planner_color_scale
+site_planner_estimate
+site_planner_estimating
+site_planner_failed
+site_planner_frequency_mhz
+site_planner_high_resolution
+site_planner_invalid_latitude
+site_planner_invalid_longitude
+site_planner_invalid_positive
+site_planner_invalid_rx_sensitivity
+site_planner_max_range_km
+site_planner_rx_height_meters
+site_planner_rx_sensitivity_dbm
+site_planner_section_display
+site_planner_section_receiver
+site_planner_section_simulation
+site_planner_section_transmitter
+site_planner_subtitle
+site_planner_tx_power_watts
+site_planner_use_current_location
+site_planner_use_map_center
+site_planner_use_node_location
skip
slot
smart_position
diff --git a/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/GetMapViewProvider.kt b/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/GetMapViewProvider.kt
index 48b1aa7fc3..146164e84d 100644
--- a/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/GetMapViewProvider.kt
+++ b/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/GetMapViewProvider.kt
@@ -19,3 +19,6 @@ package org.meshtastic.app.map
import org.meshtastic.core.ui.util.MapViewProvider
fun getMapViewProvider(): MapViewProvider = FdroidMapViewProvider()
+
+/** Site Planner (coverage-estimate) is Google-flavor only; the F-Droid map has no overlay-layer support. */
+fun sitePlannerAvailable(): Boolean = false
diff --git a/androidApp/src/google/AndroidManifest.xml b/androidApp/src/google/AndroidManifest.xml
index bab70fc2dc..835daf5596 100644
--- a/androidApp/src/google/AndroidManifest.xml
+++ b/androidApp/src/google/AndroidManifest.xml
@@ -29,6 +29,35 @@
android:name="android.app.appfunctions.app_metadata"
android:resource="@xml/app_metadata" />
+ <!--
+ Register as an "Open in / Send to Meshtastic" target for GeoJSON/KML map files (e.g. the Meshtastic Site
+ Planner's "Send to App" share). Google flavor only — it's the flavor that renders map overlays. Merged into
+ the MainActivity declared in the base manifest. application/json is broad, but the map importer validates the
+ file extension and ignores anything that isn't a supported layer.
+ -->
+ <activity
+ android:name="org.meshtastic.app.MainActivity"
+ tools:node="merge">
+ <intent-filter>
+ <action android:name="android.intent.action.VIEW" />
+ <category android:name="android.intent.category.DEFAULT" />
+ <data android:mimeType="application/geo+json" />
+ <data android:mimeType="application/vnd.geo+json" />
+ <data android:mimeType="application/json" />
+ <data android:mimeType="application/vnd.google-earth.kml+xml" />
+ <data android:mimeType="application/vnd.google-earth.kmz" />
+ </intent-filter>
+ <intent-filter>
+ <action android:name="android.intent.action.SEND" />
+ <category android:name="android.intent.category.DEFAULT" />
+ <data android:mimeType="application/geo+json" />
+ <data android:mimeType="application/vnd.geo+json" />
+ <data android:mimeType="application/json" />
+ <data android:mimeType="application/vnd.google-earth.kml+xml" />
+ <data android:mimeType="application/vnd.google-earth.kmz" />
+ </intent-filter>
+ </activity>
+
<!--
AppFunctions 1.0.0-alpha10 discontinued the appfunctions-service artifact whose
manifest used to contribute these two delegate services; apps must now declare
diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/map/GetMapViewProvider.kt b/androidApp/src/google/kotlin/org/meshtastic/app/map/GetMapViewProvider.kt
index 8a441fa70a..49025064fe 100644
--- a/androidApp/src/google/kotlin/org/meshtastic/app/map/GetMapViewProvider.kt
+++ b/androidApp/src/google/kotlin/org/meshtastic/app/map/GetMapViewProvider.kt
@@ -19,3 +19,6 @@ package org.meshtastic.app.map
import org.meshtastic.core.ui.util.MapViewProvider
fun getMapViewProvider(): MapViewProvider = GoogleMapViewProvider()
+
+/** Site Planner (coverage-estimate) availability — Google flavor only (the flavor that renders map overlays). */
+fun sitePlannerAvailable(): Boolean = true
diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt b/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt
index f3425d7a78..6fb1b77649 100644
--- a/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt
+++ b/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt
@@ -18,8 +18,10 @@
package org.meshtastic.app.map
+import android.annotation.SuppressLint
import android.app.Activity
import android.content.Intent
+import android.location.Location
import android.net.Uri
import android.view.WindowManager
import androidx.activity.compose.rememberLauncherForActivityResult
@@ -61,6 +63,7 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import co.touchlab.kermit.Logger
+import com.google.android.gms.location.FusedLocationProviderClient
import com.google.android.gms.location.LocationCallback
import com.google.android.gms.location.LocationRequest
import com.google.android.gms.location.LocationResult
@@ -97,6 +100,7 @@ import com.google.maps.android.data.geojson.GeoJsonPolygonStyle
import com.google.maps.android.data.kml.KmlLayer
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
+import kotlinx.coroutines.suspendCancellableCoroutine
import org.jetbrains.compose.resources.stringResource
import org.json.JSONObject
import org.koin.compose.viewmodel.koinViewModel
@@ -119,6 +123,7 @@ import org.meshtastic.core.model.util.GeoConstants.HEADING_DEG
import org.meshtastic.core.model.util.metersIn
import org.meshtastic.core.model.util.mpsToKmph
import org.meshtastic.core.model.util.mpsToMph
+import org.meshtastic.core.model.util.primaryChannel
import org.meshtastic.core.model.util.toString
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.alt
@@ -150,14 +155,19 @@ import org.meshtastic.feature.map.LastHeardFilter
import org.meshtastic.feature.map.component.EditWaypointDialog
import org.meshtastic.feature.map.component.MapButton
import org.meshtastic.feature.map.component.MapControlsOverlay
+import org.meshtastic.feature.map.component.SitePlannerParams
import org.meshtastic.feature.map.component.WaypointInfoDialog
import org.meshtastic.feature.map.tracerouteNodeSelection
import org.meshtastic.proto.BoundingBox
+import org.meshtastic.proto.ChannelSet
import org.meshtastic.proto.Config.DisplayConfig.DisplayUnits
+import org.meshtastic.proto.Config.LoRaConfig.ModemPreset
import org.meshtastic.proto.Position
import org.meshtastic.proto.Waypoint
+import kotlin.coroutines.resume
import kotlin.math.abs
import kotlin.math.max
+import kotlin.math.pow
import kotlin.math.roundToInt
import android.graphics.Color as AndroidColor
@@ -250,6 +260,7 @@ fun MapView(
var mapFilterMenuExpanded by remember { mutableStateOf(false) }
val mapFilterState by mapViewModel.mapFilterStateFlow.collectAsStateWithLifecycle()
val ourNodeInfo by mapViewModel.ourNodeInfo.collectAsStateWithLifecycle()
+ val channelSet by mapViewModel.channelSet.collectAsStateWithLifecycle()
var editingWaypoint by remember { mutableStateOf<Waypoint?>(null) }
var geofenceInfoWaypoint by remember { mutableStateOf<Waypoint?>(null) }
val displayUnits by mapViewModel.displayUnits.collectAsStateWithLifecycle()
@@ -509,6 +520,8 @@ fun MapView(
// --- Tile & layers state ---
var showLayersBottomSheet by remember { mutableStateOf(false) }
+ // Non-null while the Site Planner estimate dialog/runner is open, holding the initial (prefilled) params.
+ var sitePlannerInitial by remember { mutableStateOf<SitePlannerParams?>(null) }
val onAddLayerClicked = {
val intent =
@@ -839,6 +852,13 @@ fun MapView(
onClick = { showLayersBottomSheet = true },
)
},
+ // Google flavor only: hands params to the hosted Site Planner and imports the returned coverage.
+ onSitePlannerClick =
+ if (sitePlannerAvailable()) {
+ { sitePlannerInitial = ourNodeInfo.toSitePlannerParams(channelSet) }
+ } else {
+ null
+ },
isLocationTrackingEnabled = isLocationTrackingEnabled,
onToggleLocationTracking = {
when {
@@ -896,6 +916,40 @@ fun MapView(
)
}
}
+ // Site Planner deep link from a node's detail screen — open the estimate dialog prefilled with that node.
+ val sitePlannerRequest by mapViewModel.sitePlannerRequest.collectAsStateWithLifecycle()
+ LaunchedEffect(sitePlannerRequest) {
+ sitePlannerRequest?.let { node ->
+ sitePlannerInitial = node.toSitePlannerParams(channelSet)
+ mapViewModel.consumeSitePlannerRequest()
+ }
+ }
+ sitePlannerInitial?.let { initial ->
+ // Phone GPS: only when permission is already granted; otherwise the field stays manual.
+ val onRequestCurrentLocation: (suspend () -> Pair<Double, Double>?)? =
+ if (locationPermission.isGranted) {
+ { fusedLocationClient.awaitLastLocation()?.let { it.latitude to it.longitude } }
+ } else {
+ null
+ }
+ // Our connected node's reported position: only when it has a valid fix.
+ val onUseNodeLocation: (() -> Pair<Double, Double>)? =
+ ourNodeInfo?.takeIf { it.validPosition != null }?.let { node -> { node.latitude to node.longitude } }
+ SitePlannerHost(
+ initialParams = initial,
+ onDismiss = { sitePlannerInitial = null },
+ onImport = { name, geoJson, latitude, longitude ->
+ mapViewModel.addGeoJsonLayer(name, geoJson)
+ // Recenter on the estimate's transmitter so the freshly-imported coverage is on-screen.
+ coroutineScope.launch {
+ cameraPositionState.animate(CameraUpdateFactory.newLatLng(LatLng(latitude, longitude)))
+ }
+ },
+ onRequestCurrentLocation = onRequestCurrentLocation,
+ onUseNodeLocation = onUseNodeLocation,
+ onUseMapCenter = { cameraPositionState.position.target.let { it.latitude to it.longitude } },
+ )
+ }
showClusterItemsDialog?.let {
ClusterItemsListDialog(
items = it,
@@ -1395,6 +1449,60 @@ fun Uri.getFileName(context: android.content.Context): String {
/** Converts protobuf [Position] integer coordinates to a Google Maps [LatLng]. */
internal fun Position.toLatLng(): LatLng = LatLng((this.latitude_i ?: 0) * DEG_D, (this.longitude_i ?: 0) * DEG_D)
+/**
+ * Seed Site Planner params from a node (name + position) and, when a radio is connected, from its actual config:
+ * transmit frequency (from the primary channel), transmit power (dBm→W), and a receiver sensitivity derived from the
+ * modem preset. Antenna gain/height aren't in the device config, so they keep the planner's stock defaults.
+ */
+private fun Node?.toSitePlannerParams(channelSet: ChannelSet?): SitePlannerParams {
+ val lora = channelSet?.lora_config
+ val freqMhz = channelSet?.primaryChannel?.radioFreq?.toDouble()?.takeIf { it > 0.0 }
+ return SitePlannerParams(
+ name = this?.user?.long_name?.takeIf { it.isNotBlank() } ?: "Coverage",
+ latitude = this?.latitude ?: 0.0,
+ longitude = this?.longitude ?: 0.0,
+ txFreqMhz = freqMhz ?: SitePlannerParams.DEFAULT_TX_FREQ_MHZ,
+ txPowerWatts = dbmToWatts(lora?.tx_power ?: 0) ?: SitePlannerParams.DEFAULT_TX_POWER_WATTS,
+ rxSensitivityDbm = sensitivityDbmFor(lora?.modem_preset),
+ )
+}
+
+/**
+ * Meshtastic tx_power is dBm; the planner wants watts. 0 (or less) means "use region max" in firmware — unknown here.
+ */
+@Suppress("MagicNumber")
+private fun dbmToWatts(dbm: Int): Double? = if (dbm <= 0) null else 10.0.pow((dbm - 30).toDouble() / 10.0)
+
+/**
+ * Approximate receiver sensitivity (dBm) per Meshtastic modem preset, from the Site Planner's own parameters.md table.
+ * Unmapped presets fall back to the planner's default so the estimate still runs.
+ */
+@Suppress("MagicNumber")
+private fun sensitivityDbmFor(preset: ModemPreset?): Double = when (preset) {
+ ModemPreset.SHORT_TURBO -> -126.0
+ ModemPreset.SHORT_FAST -> -129.0
+ ModemPreset.SHORT_SLOW -> -131.5
+ ModemPreset.MEDIUM_FAST -> -134.0
+ ModemPreset.MEDIUM_SLOW -> -136.5
+ ModemPreset.LONG_FAST -> -139.0
+ ModemPreset.LONG_MODERATE -> -142.0
+ ModemPreset.LONG_SLOW -> -144.5
+ ModemPreset.VERY_LONG_SLOW -> -147.5
+ else -> SitePlannerParams.DEFAULT_RX_SENSITIVITY_DBM
+}
+
+/** One-shot last known location as a suspend call. Guarded by a permission check at the call site. */
+@SuppressLint("MissingPermission")
+private suspend fun FusedLocationProviderClient.awaitLastLocation(): Location? = suspendCancellableCoroutine { cont ->
+ // lastLocation can throw SecurityException synchronously if permission is revoked between the compose-time
+ // isGranted check and this call; treat that as "no location" rather than crashing the estimate flow.
+ try {
+ lastLocation.addOnSuccessListener { cont.resume(it) }.addOnFailureListener { cont.resume(null) }
+ } catch (_: SecurityException) {
+ cont.resume(null)
+ }
+}
+
/** Builds a proto [BoundingBox] (degrees ×1e7) from two opposite corner taps. */
private fun boundingBoxFromCorners(a: LatLng, b: LatLng): BoundingBox = BoundingBox(
longitude_west_i = (minOf(a.longitude, b.longitude) / DEG_D).toInt(),
diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/map/MapViewModel.kt b/androidApp/src/google/kotlin/org/meshtastic/app/map/MapViewModel.kt
index 6cd8a7b7df..d82ecfa45f 100644
--- a/androidApp/src/google/kotlin/org/meshtastic/app/map/MapViewModel.kt
+++ b/androidApp/src/google/kotlin/org/meshtastic/app/map/MapViewModel.kt
@@ -39,16 +39,19 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asFlow
import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import org.koin.core.annotation.KoinViewModel
+import org.meshtastic.app.MapFileImportBus
import org.meshtastic.app.map.model.CustomTileProviderConfig
import org.meshtastic.app.map.prefs.map.GoogleMapsPrefs
import org.meshtastic.app.map.repository.CustomTileProviderRepository
import org.meshtastic.core.di.CoroutineDispatchers
+import org.meshtastic.core.model.Node
import org.meshtastic.core.model.NodeAddress
import org.meshtastic.core.repository.MapPrefs
import org.meshtastic.core.repository.NodeRepository
@@ -106,6 +109,17 @@ class MapViewModel(
private val _selectedWaypointId = MutableStateFlow(savedStateHandle.get<Int>("waypointId"))
val selectedWaypointId: StateFlow<Int?> = _selectedWaypointId.asStateFlow()
+ // Site Planner deep link from node detail: MapRoute.Map(sitePlannerNodeNum) → resolve to the node so the map can
+ // open the estimate dialog pre-filled with its position. Cleared once consumed so it doesn't re-open.
+ private val pendingSitePlannerNodeNum = MutableStateFlow(savedStateHandle.get<Int>("sitePlannerNodeNum"))
+ val sitePlannerRequest: StateFlow<Node?> =
+ combine(pendingSitePlannerNodeNum, nodeRepository.nodeDBbyNum) { num, db -> num?.let { db[it] } }
+ .stateInWhileSubscribed(initialValue = null)
+
+ fun consumeSitePlannerRequest() {
+ pendingSitePlannerNodeNum.value = null
+ }
+
fun setWaypointId(id: Int?) {
if (_selectedWaypointId.value != id) {
_selectedWaypointId.value = id
@@ -362,6 +376,15 @@ class MapViewModel(
}
loadPersistedLayers()
+ // Import a map file handed to us via an "Open in / Send to Meshtastic" intent (see MapFileImportBus).
+ viewModelScope.launch {
+ MapFileImportBus.pending.collect { uri ->
+ uri ?: return@collect
+ MapFileImportBus.pending.value = null
+ addMapLayer(uri, uri.getFileName(application))
+ }
+ }
+
selectedWaypointId.value?.let { wpId ->
viewModelScope.launch {
val wpMap = waypoints.first { it.containsKey(wpId) }
@@ -581,6 +604,37 @@ class MapViewModel(
}
}
+ /**
+ * Import a GeoJSON string (e.g. handed back by the Site Planner headless bridge) as a visible local overlay,
+ * reusing the same internal-storage-backed layer plumbing as file imports.
+ */
+ fun addGeoJsonLayer(name: String, geoJson: String) {
+ viewModelScope.launch {
+ val displayName = name.ifBlank { "Coverage" }
+ val safeFileName = displayName.replace(Regex("[^A-Za-z0-9._-]"), "_")
+ val uri = writeStringToInternalStorage(geoJson, "${safeFileName}_${Uuid.random()}.geojson")
+ if (uri != null) {
+ _mapLayers.value =
+ _mapLayers.value + MapLayerItem(name = displayName, uri = uri, layerType = LayerType.GEOJSON)
+ } else {
+ Logger.withTag("MapViewModel").e("Failed to write GeoJSON layer to internal storage.")
+ }
+ }
+ }
+
+ private suspend fun writeStringToInternalStorage(content: String, fileName: String): Uri? =
+ withContext(dispatchers.io) {
+ try {
+ val directory = File(application.filesDir, "map_layers").apply { if (!exists()) mkdirs() }
+ val outputFile = File(directory, fileName)
+ outputFile.writeText(content)
+ Uri.fromFile(outputFile)
+ } catch (e: IOException) {
+ Logger.withTag("MapViewModel").e(e) { "Error writing GeoJSON to internal storage" }
+ null
+ }
+ }
+
fun toggleLayerVisibility(layerId: String) {
var toggledLayer: MapLayerItem? = null
val updatedLayers =
diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/map/SitePlannerRunner.kt b/androidApp/src/google/kotlin/org/meshtastic/app/map/SitePlannerRunner.kt
new file mode 100644
index 0000000000..2863d8c2f4
--- /dev/null
+++ b/androidApp/src/google/kotlin/org/meshtastic/app/map/SitePlannerRunner.kt
@@ -0,0 +1,231 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.app.map
+
+import android.annotation.SuppressLint
+import android.net.Uri
+import android.os.Handler
+import android.os.Looper
+import android.webkit.JavascriptInterface
+import android.webkit.WebResourceError
+import android.webkit.WebResourceRequest
+import android.webkit.WebView
+import android.webkit.WebViewClient
+import android.widget.Toast
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.material3.CircularWavyProgressIndicator
+import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.rememberUpdatedState
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.alpha
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.viewinterop.AndroidView
+import androidx.compose.ui.window.Dialog
+import co.touchlab.kermit.Logger
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+import org.jetbrains.compose.resources.stringResource
+import org.meshtastic.core.resources.Res
+import org.meshtastic.core.resources.cancel
+import org.meshtastic.core.resources.site_planner_estimating
+import org.meshtastic.core.resources.site_planner_failed
+import org.meshtastic.feature.map.component.SitePlannerParams
+import org.meshtastic.feature.map.component.SitePlannerSheet
+
+// The official hosted Site Planner (static PWA on GitHub Pages). The estimate flow loads it headless with
+// run=1&bridge=1; the native bridge + full flat query contract it relies on shipped in site-planner #74.
+// ponytail: make it a setting if a self-hosted planner is ever needed.
+private const val SITE_PLANNER_BASE_URL = "https://site.meshtastic.org"
+private const val SITE_PLANNER_TIMEOUT_MS = 45_000L
+
+/**
+ * Site Planner coverage-estimate flow: an editable [SitePlannerSheet] pre-filled with [initialParams], then a hidden
+ * headless WebView that loads the planner with `run=1&bridge=1`, waits for it to hand back the styled GeoJSON, and
+ * imports it via [onImport]. Location shortcuts re-seed the coordinates from the device GPS
+ * ([onRequestCurrentLocation], when permission is granted), this node ([onUseNodeLocation]), or the map center
+ * ([onUseMapCenter]). Google-flavor affordance; targets the official hosted planner.
+ */
+@OptIn(ExperimentalMaterial3ExpressiveApi::class)
+@Composable
+fun SitePlannerHost(
+ initialParams: SitePlannerParams,
+ onDismiss: () -> Unit,
+ onImport: (name: String, geoJson: String, latitude: Double, longitude: Double) -> Unit,
+ onRequestCurrentLocation: (suspend () -> Pair<Double, Double>?)? = null,
+ onUseNodeLocation: (() -> Pair<Double, Double>)? = null,
+ onUseMapCenter: (() -> Pair<Double, Double>)? = null,
+) {
+ var params by remember(initialParams) { mutableStateOf(initialParams) }
+ var running by remember { mutableStateOf<SitePlannerParams?>(null) }
+ val scope = rememberCoroutineScope()
+ val context = LocalContext.current
+ val failedText = stringResource(Res.string.site_planner_failed)
+ val estimatingText = stringResource(Res.string.site_planner_estimating)
+
+ val current = running
+ if (current == null) {
+ SitePlannerSheet(
+ initial = params,
+ onSubmit = { running = it },
+ onDismiss = onDismiss,
+ onUseCurrentLocation =
+ onRequestCurrentLocation?.let { fetch ->
+ {
+ scope.launch {
+ fetch()?.let { (lat, lon) -> params = params.copy(latitude = lat, longitude = lon) }
+ }
+ }
+ },
+ onUseNodeLocation =
+ onUseNodeLocation?.let { node ->
+ {
+ val (lat, lon) = node()
+ params = params.copy(latitude = lat, longitude = lon)
+ }
+ },
+ onUseMapCenter =
+ onUseMapCenter?.let { center ->
+ {
+ val (lat, lon) = center()
+ params = params.copy(latitude = lat, longitude = lon)
+ }
+ },
+ )
+ } else {
+ Dialog(onDismissRequest = { onDismiss() }) {
+ // The WebView fills the card and runs the sim; the opaque spinner Surface on top hides it, so the flow
+ // reads as "Estimating coverage…" rather than a browser. It must be attached and non-trivially sized —
+ // a detached or 0-size WebView can't get a WebGL context, and the planner's autorun waits on map load.
+ Box(modifier = Modifier.size(280.dp), contentAlignment = Alignment.Center) {
+ SitePlannerRunner(
+ url = current.toQueryUrl(SITE_PLANNER_BASE_URL),
+ onResult = { geoJson ->
+ onImport(current.name, geoJson, current.latitude, current.longitude)
+ onDismiss()
+ },
+ onError = { detail ->
+ Logger.withTag("SitePlanner").e { "Coverage estimate failed: $detail" }
+ Toast.makeText(context, failedText, Toast.LENGTH_SHORT).show()
+ onDismiss()
+ },
+ // Headless: fully transparent so the WebGL first-paint frame never flashes through, but still
+ // attached + sized (280dp) so the sim gets a WebGL context. alpha(0) is a compositor property —
+ // the view stays VISIBLE, so the page's requestAnimationFrame/autorun keep running.
+ modifier = Modifier.matchParentSize().alpha(0f),
+ )
+ Surface(
+ modifier = Modifier.matchParentSize(),
+ shape = MaterialTheme.shapes.large,
+ color = MaterialTheme.colorScheme.surface,
+ ) {
+ Column(
+ modifier = Modifier.padding(24.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically),
+ ) {
+ CircularWavyProgressIndicator()
+ Text(estimatingText)
+ TextButton(onClick = onDismiss) { Text(stringResource(Res.string.cancel)) }
+ }
+ }
+ }
+ }
+ LaunchedEffect(current) {
+ delay(SITE_PLANNER_TIMEOUT_MS)
+ Logger.withTag("SitePlanner").w { "Coverage estimate timed out after ${SITE_PLANNER_TIMEOUT_MS}ms" }
+ Toast.makeText(context, failedText, Toast.LENGTH_SHORT).show()
+ onDismiss()
+ }
+ }
+}
+
+/**
+ * Headless WebView that loads [url] (the planner with `run=1&bridge=1`), exposes a `window.__meshtasticNative` bridge,
+ * and reports the coverage GeoJSON via [onResult] once the planner posts it. Main-frame load failures go to [onError].
+ */
+@SuppressLint("SetJavaScriptEnabled")
+@Composable
+private fun SitePlannerRunner(
+ url: String,
+ onResult: (String) -> Unit,
+ onError: (String) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val onResultState by rememberUpdatedState(onResult)
+ val onErrorState by rememberUpdatedState(onError)
+ AndroidView(
+ modifier = modifier,
+ factory = { context ->
+ val main = Handler(Looper.getMainLooper())
+ WebView(context).apply {
+ setBackgroundColor(android.graphics.Color.TRANSPARENT) // no opaque black backing before first paint
+ settings.javaScriptEnabled = true
+ settings.domStorageEnabled = true
+ addJavascriptInterface(
+ object {
+ // Called from the planner's JS thread — hop to main before touching Compose state.
+ @JavascriptInterface fun onCoverage(geoJson: String) = main.post { onResultState(geoJson) }
+ },
+ "__meshtasticNative",
+ )
+ webViewClient =
+ object : WebViewClient() {
+ // Keep the __meshtasticNative bridge exclusive to the planner's own origin: block any
+ // navigation
+ // elsewhere (redirect/compromise/open-redirect) so foreign content can never reach
+ // onCoverage().
+ // Sub-resource fetches (tiles, XHR) aren't navigations, so this doesn't affect the sim itself.
+ override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean {
+ val target = request?.url ?: return false
+ val trusted = Uri.parse(SITE_PLANNER_BASE_URL)
+ return target.scheme != trusted.scheme || target.host != trusted.host
+ }
+
+ override fun onReceivedError(
+ view: WebView?,
+ request: WebResourceRequest?,
+ error: WebResourceError?,
+ ) {
+ // Only a failed main-frame load is fatal; a stray tile/asset 404 is not.
+ if (request?.isForMainFrame == true) {
+ main.post { onErrorState("${error?.errorCode} ${error?.description}") }
+ }
+ }
+ }
+ loadUrl(url)
+ }
+ },
+ onRelease = WebView::destroy,
+ )
+}
diff --git a/androidApp/src/main/kotlin/org/meshtastic/app/MainActivity.kt b/androidApp/src/main/kotlin/org/meshtastic/app/MainActivity.kt
index 628336ca5d..288b7bb9c2 100644
--- a/androidApp/src/main/kotlin/org/meshtastic/app/MainActivity.kt
+++ b/androidApp/src/main/kotlin/org/meshtastic/app/MainActivity.kt
@@ -54,6 +54,7 @@ import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.parameter.parametersOf
import org.meshtastic.app.intro.AnalyticsIntro
import org.meshtastic.app.map.getMapViewProvider
+import org.meshtastic.app.map.sitePlannerAvailable
import org.meshtastic.app.node.component.InlineMap
import org.meshtastic.app.node.metrics.getTracerouteMapOverlayInsets
import org.meshtastic.app.ui.MainScreen
@@ -81,6 +82,7 @@ import org.meshtastic.core.ui.util.LocalNfcScannerSupported
import org.meshtastic.core.ui.util.LocalNfcWriterProvider
import org.meshtastic.core.ui.util.LocalNodeMapScreenProvider
import org.meshtastic.core.ui.util.LocalNodeTrackMapProvider
+import org.meshtastic.core.ui.util.LocalSitePlannerAvailable
import org.meshtastic.core.ui.util.LocalTracerouteMapOverlayInsetsProvider
import org.meshtastic.core.ui.util.LocalTracerouteMapProvider
import org.meshtastic.core.ui.util.LocalTracerouteMapScreenProvider
@@ -199,6 +201,7 @@ class MainActivity : AppCompatActivity() {
LocalNfcScannerSupported provides true,
LocalAnalyticsIntroProvider provides { AnalyticsIntro() },
LocalMapViewProvider provides getMapViewProvider(),
+ LocalSitePlannerAvailable provides sitePlannerAvailable(),
LocalInlineMapProvider provides { node, modifier -> InlineMap(node, modifier) },
LocalNodeTrackMapProvider provides
{ destNum, positions, modifier, selectedPositionTime, onPositionSelected ->
@@ -262,9 +265,7 @@ class MainActivity : AppCompatActivity() {
val appLinkData: Uri? = intent.data
when (appLinkAction) {
- Intent.ACTION_VIEW -> {
- appLinkData?.let { handleMeshtasticUri(it) }
- }
+ Intent.ACTION_VIEW -> handleViewIntent(appLinkData)
NfcAdapter.ACTION_NDEF_DISCOVERED -> {
val rawMessages =
@@ -295,12 +296,7 @@ class MainActivity : AppCompatActivity() {
Intent.ACTION_MAIN -> {}
- Intent.ACTION_SEND -> {
- val text = intent.getStringExtra(Intent.EXTRA_TEXT)
- if (text != null) {
- createShareIntent(text).send()
- }
- }
+ Intent.ACTION_SEND -> handleSendIntent(intent)
else -> {
Logger.w { "Unexpected action $appLinkAction" }
@@ -308,12 +304,46 @@ class MainActivity : AppCompatActivity() {
}
}
+ private fun handleViewIntent(uri: Uri?) {
+ when {
+ uri == null -> {}
+
+ // A file handed to us via "Open in Meshtastic" (Files, Share Sheet, drag-and-drop) rather than a
+ // meshtastic:// / https deep link — import it as a map overlay.
+ uri.scheme == "content" || uri.scheme == "file" -> importMapFile(uri)
+
+ else -> handleMeshtasticUri(uri)
+ }
+ }
+
+ private fun handleSendIntent(intent: Intent) {
+ val stream = IntentCompat.getParcelableExtra(intent, Intent.EXTRA_STREAM, Uri::class.java)
+ val text = intent.getStringExtra(Intent.EXTRA_TEXT)
+ when {
+ stream != null -> importMapFile(stream)
+
+ // shared .geojson/.kml file (e.g. Site Planner)
+ text != null -> createShareIntent(text).send()
+ }
+ }
+
private fun handleMeshtasticUri(uri: Uri) {
Logger.d { "Handling Meshtastic URI: $uri" }
model.handleDeepLink(uri.toKmpUri()) { lifecycleScope.launch { showToast(Res.string.channel_invalid) } }
}
+ /**
+ * Hand a map file received via an OS "Open in / Send to Meshtastic" intent to the map, then bring the Map tab
+ * forward so the imported overlay is visible. Only the Google-flavor map consumes this (see [MapFileImportBus]);
+ * the read grant on [uri] lives as long as this activity, which is long enough for the map to copy the file in.
+ */
+ private fun importMapFile(uri: Uri) {
+ Logger.d { "Importing shared map file: $uri" }
+ MapFileImportBus.pending.value = uri
+ handleMeshtasticUri("$DEEP_LINK_BASE_URI/map".toUri())
+ }
+
private fun createShareIntent(message: String): PendingIntent {
val deepLink = "$DEEP_LINK_BASE_URI/share?message=$message"
val startActivityIntent =
diff --git a/androidApp/src/main/kotlin/org/meshtastic/app/MapFileImportBus.kt b/androidApp/src/main/kotlin/org/meshtastic/app/MapFileImportBus.kt
new file mode 100644
index 0000000000..ef467b7255
--- /dev/null
+++ b/androidApp/src/main/kotlin/org/meshtastic/app/MapFileImportBus.kt
@@ -0,0 +1,33 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.app
+
+import android.net.Uri
+import kotlinx.coroutines.flow.MutableStateFlow
+
+/**
+ * One-slot handoff for a map file (GeoJSON/KML) received via an "Open in / Send to Meshtastic" intent (e.g. the
+ * Meshtastic Site Planner's "Send to App" share). The shared [MainActivity] receives the intent; only the Google-flavor
+ * map renders overlays, so its `MapViewModel` drains this and imports the layer while the activity still holds the URI
+ * read grant.
+ *
+ * ponytail: process-global single slot — fine for one shared file at a time; promote to a Koin single if this ever
+ * needs a second consumer or unit testing.
+ */
+object MapFileImportBus {
+ val pending = MutableStateFlow<Uri?>(null)
+}
diff --git a/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/Routes.kt b/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/Routes.kt
index bb65c4f04b..2c375a6de4 100644
--- a/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/Routes.kt
+++ b/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/Routes.kt
@@ -54,7 +54,7 @@ sealed interface ContactsRoute : Route {
@Serializable
sealed interface MapRoute : Route {
- @Serializable data class Map(val waypointId: Int? = null) : MapRoute
+ @Serializable data class Map(val waypointId: Int? = null, val sitePlannerNodeNum: Int? = null) : MapRoute
}
@Serializable
diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml
index 2579faeb85..7db50f3746 100644
--- a/core/resources/src/commonMain/composeResources/values/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values/strings.xml
@@ -1492,6 +1492,32 @@
<string name="shutdown_warning">⚠️ This will SHUTDOWN the node. Physical interaction will be required to turn it back on.</string>
<string name="signal">Signal</string>
<string name="signal_quality">Signal Quality</string>
+ <!-- SITE -->
+ <string name="site_planner">Site Planner</string>
+ <string name="site_planner_antenna_gain_dbi">Antenna gain (dBi)</string>
+ <string name="site_planner_antenna_height_meters">Antenna height (m)</string>
+ <string name="site_planner_color_scale">Color palette</string>
+ <string name="site_planner_estimate">Estimate coverage</string>
+ <string name="site_planner_estimating">Estimating coverage…</string>
+ <string name="site_planner_failed">Coverage estimate failed</string>
+ <string name="site_planner_frequency_mhz">Frequency (MHz)</string>
+ <string name="site_planner_high_resolution">High-resolution terrain</string>
+ <string name="site_planner_invalid_latitude">Enter −90 to 90</string>
+ <string name="site_planner_invalid_longitude">Enter −180 to 180</string>
+ <string name="site_planner_invalid_positive">Must be greater than 0</string>
+ <string name="site_planner_invalid_rx_sensitivity">Enter −150 to −30 dBm</string>
+ <string name="site_planner_max_range_km">Max range (km)</string>
+ <string name="site_planner_rx_height_meters">Receiver height (m)</string>
+ <string name="site_planner_rx_sensitivity_dbm">Receiver sensitivity (dBm)</string>
+ <string name="site_planner_section_display">Display</string>
+ <string name="site_planner_section_receiver">Receiver</string>
+ <string name="site_planner_section_simulation">Simulation Options</string>
+ <string name="site_planner_section_transmitter">Site / Transmitter</string>
+ <string name="site_planner_subtitle">Estimate RF coverage for a transmitter</string>
+ <string name="site_planner_tx_power_watts">TX power (W)</string>
+ <string name="site_planner_use_current_location">Use current location</string>
+ <string name="site_planner_use_map_center">Use map center</string>
+ <string name="site_planner_use_node_location">Use current node location</string>
<string name="skip">Skip</string>
<string name="slot">Slot</string>
<string name="smart_position">Smart Position</string>
diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalSitePlannerAvailable.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalSitePlannerAvailable.kt
new file mode 100644
index 0000000000..10293e3435
--- /dev/null
+++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalSitePlannerAvailable.kt
@@ -0,0 +1,30 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.core.ui.util
+
+import androidx.compose.runtime.compositionLocalOf
+
+/**
+ * Whether the Site Planner coverage-estimate flow is available (Google flavor only, backed by the official hosted
+ * planner). Gates the "Estimate coverage" action on the node detail screen; the map's own control is gated equivalently
+ * in the Google MapView. Defaults to false so F-Droid/release never surface a dead action.
+ *
+ * Flavor-injected feature availability, mirroring the other Local*Provider seams in this package (all baselined for
+ * this rule); a plain boolean local is the lightest way to gate a commonMain UI action on a flavor capability.
+ */
+@Suppress("CompositionLocalAllowlist")
+val LocalSitePlannerAvailable = compositionLocalOf { false }
diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt
index f84647a774..aabba7f836 100644
--- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt
+++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt
@@ -48,6 +48,7 @@ import org.meshtastic.core.resources.one_hour
import org.meshtastic.core.resources.two_days
import org.meshtastic.core.ui.viewmodel.safeLaunch
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
+import org.meshtastic.proto.ChannelSet
import org.meshtastic.proto.Config.DisplayConfig.DisplayUnits
import org.meshtastic.proto.Position
import org.meshtastic.proto.Waypoint
@@ -79,6 +80,12 @@ open class BaseMapViewModel(
val ourNodeInfo = nodeRepository.ourNodeInfo
+ /**
+ * Connected radio's channel set (primary-channel frequency + LoRa config); used to prefill a Site Planner estimate.
+ */
+ val channelSet: StateFlow<ChannelSet?> =
+ radioConfigRepository.channelSetFlow.stateInWhileSubscribed(initialValue = null)
+
val myNodeNum
get() = myNodeInfo.value?.myNodeNum
diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapControlsOverlay.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapControlsOverlay.kt
index 454bc72b22..b71585034c 100644
--- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapControlsOverlay.kt
+++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapControlsOverlay.kt
@@ -35,7 +35,9 @@ import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.map_filter
import org.meshtastic.core.resources.orient_north
import org.meshtastic.core.resources.refresh
+import org.meshtastic.core.resources.site_planner
import org.meshtastic.core.resources.toggle_my_position
+import org.meshtastic.core.ui.icon.CellTower
import org.meshtastic.core.ui.icon.LocationDisabled
import org.meshtastic.core.ui.icon.MapCompass
import org.meshtastic.core.ui.icon.MeshtasticIcons
@@ -72,6 +74,7 @@ fun MapControlsOverlay(
filterDropdownContent: @Composable () -> Unit = {},
mapTypeContent: @Composable () -> Unit = {},
layersContent: @Composable () -> Unit = {},
+ onSitePlannerClick: (() -> Unit)? = null,
isLocationTrackingEnabled: Boolean = false,
onToggleLocationTracking: () -> Unit = {},
showRefresh: Boolean = false,
@@ -102,6 +105,15 @@ fun MapControlsOverlay(
// Layers button (flavor-specific)
layersContent()
+ // Site Planner coverage estimate (optional; provided by the Google flavor)
+ onSitePlannerClick?.let { onClick ->
+ MapButton(
+ icon = MeshtasticIcons.CellTower,
+ contentDescription = stringResource(Res.string.site_planner),
+ onClick = onClick,
+ )
+ }
+
// Refresh button (optional)
if (showRefresh) {
if (isRefreshing) {
diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/SitePlannerParams.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/SitePlannerParams.kt
new file mode 100644
index 0000000000..b3e4c6069c
--- /dev/null
+++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/SitePlannerParams.kt
@@ -0,0 +1,126 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.feature.map.component
+
+/**
+ * Transmitter parameters for a Meshtastic Site Planner coverage estimate. Maps onto the planner's flat query contract
+ * (`?lat=&lon=&name=&tx_power=&tx_freq=&tx_height=&tx_gain=`), so [toQueryUrl] can hand a configured, auto-running view
+ * to the planner without knowing its internal parameter schema.
+ */
+data class SitePlannerParams(
+ val name: String,
+ val latitude: Double,
+ val longitude: Double,
+ val txPowerWatts: Double = DEFAULT_TX_POWER_WATTS,
+ val txFreqMhz: Double = DEFAULT_TX_FREQ_MHZ,
+ val txHeightMeters: Double = DEFAULT_TX_HEIGHT_METERS,
+ val txGainDbi: Double = DEFAULT_TX_GAIN_DBI,
+ val colorScale: String = DEFAULT_COLOR_SCALE,
+ // Advanced fields — default to the planner's own values, so sending them is behaviorally identical to omitting
+ // them (they just make the hand-off explicit). Surfaced behind an "Advanced" section in the form.
+ val rxSensitivityDbm: Double = DEFAULT_RX_SENSITIVITY_DBM,
+ val rxHeightMeters: Double = DEFAULT_RX_HEIGHT_METERS,
+ val maxRangeKm: Double = DEFAULT_MAX_RANGE_KM,
+ val highResolution: Boolean = false,
+ val minDbm: Double = DEFAULT_MIN_DBM,
+ val maxDbm: Double = DEFAULT_MAX_DBM,
+ val overlayTransparency: Int = DEFAULT_OVERLAY_TRANSPARENCY,
+) {
+ /**
+ * Build the planner URL that prefills these params and auto-runs the simulation (`run=1`), asking the planner to
+ * hand the result to a native host bridge (`bridge=1`) rather than the share sheet. Advanced params map onto the
+ * planner's flat query contract (receiver / simulation / display sections).
+ */
+ fun toQueryUrl(baseUrl: String): String {
+ val query = buildString {
+ append("lat=").append(latitude)
+ append("&lon=").append(longitude)
+ append("&name=").append(encodeQueryComponent(name))
+ append("&tx_power=").append(txPowerWatts)
+ append("&tx_freq=").append(txFreqMhz)
+ append("&tx_height=").append(txHeightMeters)
+ append("&tx_gain=").append(txGainDbi)
+ append("&color_scale=").append(encodeQueryComponent(colorScale))
+ append("&rx_sensitivity=").append(rxSensitivityDbm)
+ append("&rx_height=").append(rxHeightMeters)
+ append("&max_range=").append(maxRangeKm)
+ if (highResolution) append("&high_res=1")
+ append("&min_dbm=").append(minDbm)
+ append("&max_dbm=").append(maxDbm)
+ append("&overlay_transparency=").append(overlayTransparency)
+ append("&run=1&bridge=1")
+ }
+ val separator = if (baseUrl.endsWith("/")) "" else "/"
+ return "$baseUrl$separator?$query"
+ }
+
+ companion object {
+ // Meshtastic-typical stock defaults; all editable in the form before submission. Values mirror the planner's
+ // own factory defaults (src/store.ts defaultParams()) so an untouched form matches a fresh planner session.
+ const val DEFAULT_TX_POWER_WATTS: Double = 0.1
+ const val DEFAULT_TX_FREQ_MHZ: Double = 907.0
+ const val DEFAULT_TX_HEIGHT_METERS: Double = 2.0
+ const val DEFAULT_TX_GAIN_DBI: Double = 2.0
+ const val DEFAULT_COLOR_SCALE: String = "plasma"
+ const val DEFAULT_RX_SENSITIVITY_DBM: Double = -130.0
+ const val DEFAULT_RX_HEIGHT_METERS: Double = 1.0
+ const val DEFAULT_MAX_RANGE_KM: Double = 30.0
+ const val DEFAULT_MIN_DBM: Double = -130.0
+ const val DEFAULT_MAX_DBM: Double = -80.0
+ const val DEFAULT_OVERLAY_TRANSPARENCY: Int = 50
+
+ // Validation ranges mirroring the planner's input constraints (src/components/*.vue).
+ const val MIN_FREQ_MHZ: Double = 20.0
+ const val MAX_FREQ_MHZ: Double = 20_000.0
+ const val MIN_RX_SENSITIVITY_DBM: Double = -150.0
+ const val MAX_RX_SENSITIVITY_DBM: Double = -30.0
+ const val MAX_RANGE_STANDARD_KM: Double = 150.0
+ const val MAX_RANGE_HIGH_RES_KM: Double = 70.0
+ const val MIN_TRANSPARENCY: Int = 0
+ const val MAX_TRANSPARENCY: Int = 100
+
+ /** Coverage palettes the planner ships (value → label), mirroring its Display.vue picker. */
+ val COLOR_SCALES: List<Pair<String, String>> =
+ listOf(
+ "plasma" to "Plasma",
+ "viridis" to "Viridis (colorblind-safe)",
+ "CMRmap" to "CMR map",
+ "cool" to "Cool",
+ "turbo" to "Turbo",
+ "jet" to "Jet",
+ )
+ }
+}
+
+private const val UNRESERVED = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_.~"
+private val HEX = "0123456789ABCDEF".toCharArray()
+private const val BYTE_MASK = 0xFF
+private const val ASCII_LIMIT = 0x80
+private const val NIBBLE_BITS = 4
+private const val NIBBLE_MASK = 0xF
+
+/** Percent-encode a query-component value (UTF-8), so names with spaces/non-ASCII survive the round trip. */
+internal fun encodeQueryComponent(value: String): String = buildString {
+ for (byte in value.encodeToByteArray()) {
+ val code = byte.toInt() and BYTE_MASK
+ if (code < ASCII_LIMIT && code.toChar() in UNRESERVED) {
+ append(code.toChar())
+ } else {
+ append('%').append(HEX[code shr NIBBLE_BITS]).append(HEX[code and NIBBLE_MASK])
+ }
+ }
+}
diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/SitePlannerSheet.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/SitePlannerSheet.kt
new file mode 100644
index 0000000000..aba76a1493
--- /dev/null
+++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/SitePlannerSheet.kt
@@ -0,0 +1,438 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+@file:OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
+@file:Suppress("TooManyFunctions") // small, single-purpose section composables mirroring the planner's panels
+
+package org.meshtastic.feature.map.component
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ColumnScope
+import androidx.compose.foundation.layout.ExperimentalLayoutApi
+import androidx.compose.foundation.layout.FlowRow
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.AssistChip
+import androidx.compose.material3.AssistChipDefaults
+import androidx.compose.material3.Button
+import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.ExposedDropdownMenuAnchorType
+import androidx.compose.material3.ExposedDropdownMenuBox
+import androidx.compose.material3.ExposedDropdownMenuDefaults
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.ModalBottomSheet
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Switch
+import androidx.compose.material3.Text
+import androidx.compose.material3.rememberModalBottomSheetState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.Stable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.semantics.heading
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.unit.dp
+import org.jetbrains.compose.resources.StringResource
+import org.jetbrains.compose.resources.stringResource
+import org.meshtastic.core.resources.Res
+import org.meshtastic.core.resources.latitude
+import org.meshtastic.core.resources.longitude
+import org.meshtastic.core.resources.name
+import org.meshtastic.core.resources.site_planner
+import org.meshtastic.core.resources.site_planner_antenna_gain_dbi
+import org.meshtastic.core.resources.site_planner_antenna_height_meters
+import org.meshtastic.core.resources.site_planner_color_scale
+import org.meshtastic.core.resources.site_planner_estimate
+import org.meshtastic.core.resources.site_planner_frequency_mhz
+import org.meshtastic.core.resources.site_planner_high_resolution
+import org.meshtastic.core.resources.site_planner_invalid_latitude
+import org.meshtastic.core.resources.site_planner_invalid_longitude
+import org.meshtastic.core.resources.site_planner_invalid_positive
+import org.meshtastic.core.resources.site_planner_invalid_rx_sensitivity
+import org.meshtastic.core.resources.site_planner_max_range_km
+import org.meshtastic.core.resources.site_planner_rx_height_meters
+import org.meshtastic.core.resources.site_planner_rx_sensitivity_dbm
+import org.meshtastic.core.resources.site_planner_section_display
+import org.meshtastic.core.resources.site_planner_section_receiver
+import org.meshtastic.core.resources.site_planner_section_simulation
+import org.meshtastic.core.resources.site_planner_section_transmitter
+import org.meshtastic.core.resources.site_planner_subtitle
+import org.meshtastic.core.resources.site_planner_tx_power_watts
+import org.meshtastic.core.resources.site_planner_use_current_location
+import org.meshtastic.core.resources.site_planner_use_map_center
+import org.meshtastic.core.resources.site_planner_use_node_location
+import org.meshtastic.core.ui.icon.ExpandLess
+import org.meshtastic.core.ui.icon.ExpandMore
+import org.meshtastic.core.ui.icon.Map
+import org.meshtastic.core.ui.icon.MeshtasticIcons
+import org.meshtastic.core.ui.icon.MyLocation
+import org.meshtastic.core.ui.icon.PinDrop
+
+private const val LAT_LIMIT = 90.0
+private const val LON_LIMIT = 180.0
+private val SWATCH_WIDTH = 40.dp
+private val SWATCH_HEIGHT = 16.dp
+
+/** Editable form state; string-backed so partial/invalid input is preserved while typing. */
+@Stable
+private class SiteFormState(initial: SitePlannerParams) {
+ var name by mutableStateOf(initial.name)
+ var lat by mutableStateOf(initial.latitude.toString())
+ var lon by mutableStateOf(initial.longitude.toString())
+ var power by mutableStateOf(initial.txPowerWatts.toString())
+ var freq by mutableStateOf(initial.txFreqMhz.toString())
+ var height by mutableStateOf(initial.txHeightMeters.toString())
+ var gain by mutableStateOf(initial.txGainDbi.toString())
+ var colorScale by mutableStateOf(initial.colorScale)
+
+ // Advanced (coverage-affecting) fields.
+ var rxSensitivity by mutableStateOf(initial.rxSensitivityDbm.toString())
+ var rxHeight by mutableStateOf(initial.rxHeightMeters.toString())
+ var maxRange by mutableStateOf(initial.maxRangeKm.toString())
+ var highResolution by mutableStateOf(initial.highResolution)
+
+ // Validation — computed from the (observable) string fields, so callers just read the booleans.
+ private val latValue
+ get() = lat.toDoubleOrNull()
+
+ private val lonValue
+ get() = lon.toDoubleOrNull()
+
+ val latBad
+ get() = latValue.let { it == null || it !in -LAT_LIMIT..LAT_LIMIT }
+
+ val lonBad
+ get() = lonValue.let { it == null || it !in -LON_LIMIT..LON_LIMIT }
+
+ val powerBad
+ get() = (power.toDoubleOrNull() ?: 0.0) <= 0.0
+
+ val freqBad
+ get() = (freq.toDoubleOrNull() ?: 0.0) <= 0.0
+
+ val rxSensBad
+ get() =
+ rxSensitivity.toDoubleOrNull()?.let {
+ it !in SitePlannerParams.MIN_RX_SENSITIVITY_DBM..SitePlannerParams.MAX_RX_SENSITIVITY_DBM
+ } ?: false
+
+ // Guard the null-island (0,0) case so an empty ocean run can't be submitted.
+ val canSubmit
+ get() = !latBad && !lonBad && !powerBad && !freqBad && !rxSensBad && (latValue != 0.0 || lonValue != 0.0)
+}
+
+/**
+ * Bottom-sheet form for a Site Planner coverage estimate, pre-filled with [initial]. [onSubmit] receives the edited
+ * params once they validate. Location shortcut chips ([onUseCurrentLocation]/[onUseNodeLocation]/[onUseMapCenter])
+ * re-seed the coordinate fields when provided, preserving edits to the other fields.
+ */
+@Composable
+fun SitePlannerSheet(
+ initial: SitePlannerParams,
+ onSubmit: (SitePlannerParams) -> Unit,
+ onDismiss: () -> Unit,
+ onUseCurrentLocation: (() -> Unit)? = null,
+ onUseNodeLocation: (() -> Unit)? = null,
+ onUseMapCenter: (() -> Unit)? = null,
+) {
+ val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
+ val state = remember { SiteFormState(initial) }
+ // Re-seed only the coordinates when a shortcut changes them; other fields keep the user's edits.
+ LaunchedEffect(initial.latitude, initial.longitude) {
+ state.lat = initial.latitude.toString()
+ state.lon = initial.longitude.toString()
+ }
+ ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) {
+ SiteFormContent(state, initial, onSubmit, onUseCurrentLocation, onUseNodeLocation, onUseMapCenter)
+ }
+}
+
+@Composable
+private fun SiteFormContent(
+ state: SiteFormState,
+ initial: SitePlannerParams,
+ onSubmit: (SitePlannerParams) -> Unit,
+ onUseCurrentLocation: (() -> Unit)?,
+ onUseNodeLocation: (() -> Unit)?,
+ onUseMapCenter: (() -> Unit)?,
+) {
+ Column(
+ modifier =
+ Modifier.fillMaxWidth()
+ .verticalScroll(rememberScrollState())
+ .padding(horizontal = 24.dp)
+ .padding(bottom = 24.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ Text(
+ text = stringResource(Res.string.site_planner),
+ style = MaterialTheme.typography.headlineSmall,
+ modifier = Modifier.semantics { heading() },
+ )
+ Text(
+ text = stringResource(Res.string.site_planner_subtitle),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ // Sections mirror the planner's own panel order + default-open state (Site Parameters drawer):
+ // Site / Transmitter (open) → Receiver → Simulation Options → Display (open).
+ TransmitterSection(state, onUseCurrentLocation, onUseNodeLocation, onUseMapCenter)
+ ReceiverSection(state)
+ SimulationSection(state)
+ DisplaySection(state)
+ Button(
+ onClick = { onSubmit(buildSubmitParams(state, initial)) },
+ enabled = state.canSubmit,
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Text(stringResource(Res.string.site_planner_estimate))
+ }
+ }
+}
+
+/** A collapsible titled section, matching the planner's `Section` panels. */
+@Composable
+private fun FormSection(title: String, defaultExpanded: Boolean, content: @Composable ColumnScope.() -> Unit) {
+ var expanded by remember { mutableStateOf(defaultExpanded) }
+ Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
+ Row(
+ modifier = Modifier.fillMaxWidth().clickable { expanded = !expanded },
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(title, style = MaterialTheme.typography.titleSmall, modifier = Modifier.semantics { heading() })
+ Icon(if (expanded) MeshtasticIcons.ExpandLess else MeshtasticIcons.ExpandMore, contentDescription = null)
+ }
+ if (expanded) content()
+ }
+}
+
+@Composable
+private fun TransmitterSection(
+ state: SiteFormState,
+ onUseCurrentLocation: (() -> Unit)?,
+ onUseNodeLocation: (() -> Unit)?,
+ onUseMapCenter: (() -> Unit)?,
+) {
+ val posMsg = stringResource(Res.string.site_planner_invalid_positive)
+ FormSection(stringResource(Res.string.site_planner_section_transmitter), defaultExpanded = true) {
+ SiteField(state.name, { state.name = it }, Res.string.name, keyboardType = KeyboardType.Text)
+ LocationChips(onUseCurrentLocation, onUseNodeLocation, onUseMapCenter)
+ TransmitterFields(
+ state = state,
+ latError = if (state.latBad) stringResource(Res.string.site_planner_invalid_latitude) else null,
+ lonError = if (state.lonBad) stringResource(Res.string.site_planner_invalid_longitude) else null,
+ powerError = if (state.powerBad) posMsg else null,
+ freqError = if (state.freqBad) posMsg else null,
+ )
+ }
+}
+
+@Composable
+private fun ReceiverSection(state: SiteFormState) {
+ FormSection(stringResource(Res.string.site_planner_section_receiver), defaultExpanded = false) {
+ SiteField(
+ state.rxSensitivity,
+ { state.rxSensitivity = it },
+ Res.string.site_planner_rx_sensitivity_dbm,
+ error = if (state.rxSensBad) stringResource(Res.string.site_planner_invalid_rx_sensitivity) else null,
+ )
+ SiteField(state.rxHeight, { state.rxHeight = it }, Res.string.site_planner_rx_height_meters)
+ }
+}
+
+@Composable
+private fun SimulationSection(state: SiteFormState) {
+ FormSection(stringResource(Res.string.site_planner_section_simulation), defaultExpanded = false) {
+ SiteField(state.maxRange, { state.maxRange = it }, Res.string.site_planner_max_range_km)
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(stringResource(Res.string.site_planner_high_resolution))
+ Switch(checked = state.highResolution, onCheckedChange = { state.highResolution = it })
+ }
+ }
+}
+
+@Composable
+private fun DisplaySection(state: SiteFormState) {
+ FormSection(stringResource(Res.string.site_planner_section_display), defaultExpanded = true) {
+ PalettePicker(state.colorScale) { state.colorScale = it }
+ }
+}
+
+/** Quick shortcuts (chips) that re-seed the coordinates from the device, this node, or the map center. */
+@Composable
+private fun LocationChips(
+ onUseCurrentLocation: (() -> Unit)?,
+ onUseNodeLocation: (() -> Unit)?,
+ onUseMapCenter: (() -> Unit)?,
+) {
+ FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ onUseCurrentLocation?.let {
+ LocationChip(it, MeshtasticIcons.MyLocation, Res.string.site_planner_use_current_location)
+ }
+ onUseNodeLocation?.let { LocationChip(it, MeshtasticIcons.PinDrop, Res.string.site_planner_use_node_location) }
+ onUseMapCenter?.let { LocationChip(it, MeshtasticIcons.Map, Res.string.site_planner_use_map_center) }
+ }
+}
+
+@Composable
+private fun LocationChip(onClick: () -> Unit, icon: ImageVector, label: StringResource) {
+ AssistChip(
+ onClick = onClick,
+ leadingIcon = { Icon(icon, null, Modifier.size(AssistChipDefaults.IconSize)) },
+ label = { Text(stringResource(label)) },
+ )
+}
+
+@Composable
+private fun TransmitterFields(
+ state: SiteFormState,
+ latError: String?,
+ lonError: String?,
+ powerError: String?,
+ freqError: String?,
+) {
+ Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
+ SiteField(state.lat, { state.lat = it }, Res.string.latitude, error = latError)
+ SiteField(state.lon, { state.lon = it }, Res.string.longitude, error = lonError)
+ SiteField(state.power, { state.power = it }, Res.string.site_planner_tx_power_watts, error = powerError)
+ SiteField(state.freq, { state.freq = it }, Res.string.site_planner_frequency_mhz, error = freqError)
+ SiteField(state.height, { state.height = it }, Res.string.site_planner_antenna_height_meters)
+ SiteField(state.gain, { state.gain = it }, Res.string.site_planner_antenna_gain_dbi)
+ }
+}
+
+@Composable
+private fun SiteField(
+ value: String,
+ onValueChange: (String) -> Unit,
+ label: StringResource,
+ modifier: Modifier = Modifier,
+ keyboardType: KeyboardType = KeyboardType.Number,
+ error: String? = null,
+) {
+ OutlinedTextField(
+ value = value,
+ onValueChange = onValueChange,
+ label = { Text(stringResource(label)) },
+ singleLine = true,
+ isError = error != null,
+ supportingText = error?.let { { Text(it) } },
+ keyboardOptions = KeyboardOptions(keyboardType = keyboardType),
+ modifier = modifier.fillMaxWidth(),
+ )
+}
+
+/** Color-scale picker with a gradient swatch for each palette (recognition over recall). */
+@Composable
+private fun PalettePicker(value: String, onChange: (String) -> Unit) {
+ var expanded by remember { mutableStateOf(false) }
+ ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = it }) {
+ OutlinedTextField(
+ value = SitePlannerParams.COLOR_SCALES.firstOrNull { it.first == value }?.second ?: value,
+ onValueChange = {},
+ readOnly = true,
+ label = { Text(stringResource(Res.string.site_planner_color_scale)) },
+ leadingIcon = { PaletteSwatch(value) },
+ trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
+ modifier = Modifier.fillMaxWidth().menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable),
+ )
+ ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
+ SitePlannerParams.COLOR_SCALES.forEach { (key, label) ->
+ DropdownMenuItem(
+ text = { Text(label) },
+ leadingIcon = { PaletteSwatch(key) },
+ onClick = {
+ onChange(key)
+ expanded = false
+ },
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun PaletteSwatch(name: String) {
+ Box(
+ Modifier.width(SWATCH_WIDTH)
+ .height(SWATCH_HEIGHT)
+ .clip(RoundedCornerShape(4.dp))
+ .background(Brush.horizontalGradient(paletteStops(name))),
+ )
+}
+
+/** Representative gradient stops per planner palette (approximations of the matplotlib LUTs for a preview swatch). */
+@Suppress("MagicNumber")
+private fun paletteStops(name: String): List<Color> {
+ val argb =
+ when (name) {
+ "viridis" -> listOf(0xFF440154, 0xFF31688E, 0xFF35B779, 0xFFFDE725)
+ "CMRmap" -> listOf(0xFF000000, 0xFF3B0F70, 0xFF8C2981, 0xFFDE4968, 0xFFFE9F6D, 0xFFFFFFFF)
+ "cool" -> listOf(0xFF00FFFF, 0xFFFF00FF)
+ "turbo" -> listOf(0xFF30123B, 0xFF4585F9, 0xFF1BD0D5, 0xFFA4FC3B, 0xFFFABA39, 0xFFE5460A, 0xFF7A0403)
+ "jet" -> listOf(0xFF000080, 0xFF0000FF, 0xFF00FFFF, 0xFFFFFF00, 0xFFFF0000, 0xFF800000)
+ else -> listOf(0xFF0D0887, 0xFF7E03A8, 0xFFCC4778, 0xFFF89540, 0xFFF0F921)
+ }
+ return argb.map { Color(it) }
+}
+
+/** Build params from the (string) form fields, falling back to the matching [initial] value when a field is invalid. */
+private fun buildSubmitParams(state: SiteFormState, initial: SitePlannerParams): SitePlannerParams = SitePlannerParams(
+ name = state.name.ifBlank { initial.name },
+ latitude = state.lat.toDoubleOrNull() ?: initial.latitude,
+ longitude = state.lon.toDoubleOrNull() ?: initial.longitude,
+ txPowerWatts = state.power.toDoubleOrNull() ?: initial.txPowerWatts,
+ txFreqMhz = state.freq.toDoubleOrNull() ?: initial.txFreqMhz,
+ txHeightMeters = state.height.toDoubleOrNull() ?: initial.txHeightMeters,
+ txGainDbi = state.gain.toDoubleOrNull() ?: initial.txGainDbi,
+ colorScale = state.colorScale,
+ rxSensitivityDbm = state.rxSensitivity.toDoubleOrNull() ?: initial.rxSensitivityDbm,
+ rxHeightMeters = state.rxHeight.toDoubleOrNull() ?: initial.rxHeightMeters,
+ maxRangeKm = state.maxRange.toDoubleOrNull() ?: initial.maxRangeKm,
+ highResolution = state.highResolution,
+ minDbm = initial.minDbm,
+ maxDbm = initial.maxDbm,
+ overlayTransparency = initial.overlayTransparency,
+)
diff --git a/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/component/SitePlannerParamsTest.kt b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/component/SitePlannerParamsTest.kt
new file mode 100644
index 0000000000..1dc447c118
--- /dev/null
+++ b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/component/SitePlannerParamsTest.kt
@@ -0,0 +1,79 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.feature.map.component
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+
+class SitePlannerParamsTest {
+
+ @Test
+ fun `toQueryUrl emits the planner's flat contract with advanced defaults and run and bridge flags`() {
+ val url =
+ SitePlannerParams(
+ name = "Tower A",
+ latitude = 51.05,
+ longitude = -114.07,
+ txPowerWatts = 0.5,
+ txFreqMhz = 915.0,
+ txHeightMeters = 12.0,
+ txGainDbi = 5.5,
+ colorScale = "turbo",
+ )
+ .toQueryUrl("http://localhost:5173")
+
+ // Advanced params carry their (planner-matching) defaults; high_res is omitted while false.
+ assertEquals(
+ "http://localhost:5173/?lat=51.05&lon=-114.07&name=Tower%20A" +
+ "&tx_power=0.5&tx_freq=915.0&tx_height=12.0&tx_gain=5.5&color_scale=turbo" +
+ "&rx_sensitivity=-130.0&rx_height=1.0&max_range=30.0" +
+ "&min_dbm=-130.0&max_dbm=-80.0&overlay_transparency=50&run=1&bridge=1",
+ url,
+ )
+ }
+
+ @Test
+ fun `toQueryUrl emits advanced overrides and high_res only when enabled`() {
+ val url =
+ SitePlannerParams(
+ name = "N",
+ latitude = 1.0,
+ longitude = 2.0,
+ rxSensitivityDbm = -139.0,
+ maxRangeKm = 60.0,
+ highResolution = true,
+ )
+ .toQueryUrl("http://localhost:5173")
+
+ assertTrue(url.contains("&rx_sensitivity=-139.0"), url)
+ assertTrue(url.contains("&max_range=60.0"), url)
+ assertTrue(url.contains("&high_res=1"), url)
+ }
+
+ @Test
+ fun `toQueryUrl does not double the slash when the base already ends in one`() {
+ val url = SitePlannerParams("N", 1.0, 2.0).toQueryUrl("https://planner.example/")
+ assertTrue(url.startsWith("https://planner.example/?lat=1.0&lon=2.0&name=N"))
+ }
+
+ @Test
+ fun `encodeQueryComponent percent-encodes spaces and non-ASCII`() {
+ assertEquals("Tower%20%C3%91%C3%B6r%C3%B0", encodeQueryComponent("Tower Ñörð"))
+ assertEquals("plain-name_1.0~", encodeQueryComponent("plain-name_1.0~"))
+ }
+}
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/PositionSection.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/PositionSection.kt
index 13db96d5ad..a21e812043 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/PositionSection.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/PositionSection.kt
@@ -38,12 +38,16 @@ import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.util.toDistanceString
+import org.meshtastic.core.navigation.MapRoute
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.open_compass
+import org.meshtastic.core.resources.site_planner_estimate
+import org.meshtastic.core.ui.icon.CellTower
import org.meshtastic.core.ui.icon.Compass
import org.meshtastic.core.ui.icon.Distance
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.util.LocalInlineMapProvider
+import org.meshtastic.core.ui.util.LocalSitePlannerAvailable
import org.meshtastic.feature.node.model.NodeDetailAction
import org.meshtastic.proto.Config
@@ -79,6 +83,24 @@ internal fun PositionInlineContent(
overflow = TextOverflow.Ellipsis,
)
}
+ // Estimate RF coverage for this node in the Site Planner (Google flavor, position-gated).
+ if (LocalSitePlannerAvailable.current && node.validPosition != null) {
+ Spacer(Modifier.height(8.dp))
+ FilledTonalButton(
+ onClick = { onAction(NodeDetailAction.Navigate(MapRoute.Map(sitePlannerNodeNum = node.num))) },
+ modifier = Modifier.fillMaxWidth(),
+ shape = MaterialTheme.shapes.large,
+ ) {
+ Icon(MeshtasticIcons.CellTower, null, Modifier.size(18.dp))
+ Spacer(Modifier.width(6.dp))
+ Text(
+ text = stringResource(Res.string.site_planner_estimate),
+ style = MaterialTheme.typography.labelLarge,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ }
}
@Composable
Served by rngit 1.5.2 - Generated in 0.28s